Skip to content

feat(desktop): fold focus-mode agent work into one transcript block - #6536

Closed
baxen wants to merge 6 commits into
ss-dev-01/berd-restylefrom
ss-dev-02/tool-chain-cards
Closed

feat(desktop): fold focus-mode agent work into one transcript block#6536
baxen wants to merge 6 commits into
ss-dev-01/berd-restylefrom
ss-dev-02/tool-chain-cards

Conversation

@baxen

@baxen baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Retargeted: the work block, not the tool-chain card

This PR previously carried Slice C's tool-chain card presentation. Per ss-core-02's direction change (2026-08-24), that presentation is superseded by berd's shipping transcript model, and this same PR is retargeted rather than replaced. ToolChainCards.tsx, the derived verb/object headline, and the card chrome are gone. Carried forward: the grouping module, step memoization, and their tests. (The useControlledDisclosure hook was carried at first, then deleted — see below.)

Head: 036bd38c3bd9dd9ea0b4652ec30e5c4609f36f6d, three commits on top of #6720's current head fd2e017998c666ccd520a0b430a3db881e32401a. Rebased onto ss-dev-01/berd-restyle (#6720) per core-02's revised ordering: B (#6538) → #6720 → C (this PR). Both must merge first.


Focus mode gave every thought and tool step its own row and its own disclosure, so a turn that did real work scrolled its answer off screen behind a stack of chrome. Now, within a turn, everything between the prompt and the final answer that is thinking, a tool step, or an interim agent note becomes one work block — a thin rail of small glyph bullets that folds to a single N steps line when the work is done.

Behaviour

  • Live — the block is open with no header line at all; the rail is the status. The last three items show on the rail in true order; anything older sits behind an N previous steps disclosure at the top.
  • Finished — folds to one summary line (6 steps · 1 failed), chevron right, rotating down on open. Clicking expands the whole rail. The fold animates: the block mounts open and settles closed after a paint, matching berd. Native <details> cannot animate height, so this uses motion (already a dep).
  • Reduced motion skips the animation — read via window.matchMedia("(prefers-reduced-motion: reduce)") with a change listener, following TerminalSubstrate.tsx. Note for reviewers: motion's own useReducedMotion caches the media query at import and MotionConfig reducedMotion does not override it; verified empirically with throwaway probes before choosing this route.
  • Reader choice wins — once the reader toggles a block, auto-open/auto-fold stops for that block.
  • Rail — thin spine, size-5 glyph bullets that mask the spine. Thought and interim-note rows get a speech-bubble glyph, tool rows a wrench, running steps pulse. Failure is a glyph shape, not a colour, so one failed step does not read as an alarm across the whole run; the tinted output block carries the red when expanded.
  • Known gap, see Agent activity: posted message text is dropped when content contains a backtick or $ #6834 — a relay-post rail step whose --content contains a backtick or $ gets a null preview from the classifier, and the rail (unlike the message bubble) has no event-fetch fallback, so it renders as a bare Sent messages row. Not fixed here; disposition waits on the rail-step-vs-message design call.

Grouping is variant-aware AT THE LIST BOUNDARY

TranscriptDisplayBlockView runs the additive work-block transform only for conversation:

isConversation ? conversationSegmentsForBlock(block) : block.segments

default and compactPreview therefore walk main's segments on the identical code path they always did, which makes Slice B's byte-for-byte baseline fixture hold by construction rather than by assertion. This is cleaner than Slice C's approach of special-casing the preview inside the component.

One projection per leaf: a closed entry type

Every admitted leaf is projected once into a rail entry, and the glyph, the body, and the folded line's counts all read that projection instead of re-asking item.type === ... at each render site. That re-derivation is what made an interim agent note fall through to the tool branch and pick up a wrench (ss-quality-00's finding 1).

The model is a discriminated union, not a { item, kind, state } product:

export type WorkBlockEntry =
  | { kind: "thought"; item: WorkBlockThoughtItem; state: "settled" }
  | { kind: "note"; item: WorkBlockNoteItem; state: "settled" }
  | { kind: "tool"; item: WorkBlockToolItem; state: WorkBlockEntryState };

Three things stop compiling as a result, each verified with a throwaway mutant rather than asserted:

Mutant Compiler says
A new admitted item type with no projection decision TS2366projectWorkBlockEntry lacks an ending return statement
A thought projected as note TS2322role missing (WorkBlockNoteItem is Extract<TranscriptItem, {type:"message"}> & { role: "assistant" })
Prose projected as failed TS2322"failed" is not assignable to "settled"

Supporting changes that make the union reach the render sites:

  • isWorkItem is a type guard, and admittedWorkItems(segment, finalAnswerId) returns WorkBlockItem[] | null instead of a boolean. An .every() predicate cannot narrow the array it just tested, so the old boolean form lost the type at the admission boundary.
  • projectWorkBlockEntry is an exhaustive switch with no default. The repo has precedent for const x: never = ... exhaustiveness checks (sound.ts, SettingsPanels.tsx), but for a returning function the missing-return error is stronger and needs no extra code.
  • toolEntryState checks running before failed, so a stale isError from a retry cannot fold a live block to N steps · 1 failed while the work is still in flight.
  • The entry is spread into WorkBlockStepBody's props rather than passed as an entry object. The projection is rebuilt on every append, so entry objects are fresh each time and a memo keyed on one would never hit; spread, the compared props are the reference-stable item plus two strings.
  • Narrowing on kind narrows item, which deleted the previous item.type === "thought" ? item.text : "" re-checks. Those silent empty-body branches are now unrepresentable rather than merely unreached.

TypeScript is stripped at runtime in this repo's .mjs tests, so the compile-time half is enforced by production code plus tsc and the runtime half by an explicit [kind, item.type] pairing assertion in agentSessionWorkBlockGrouping.test.mjs. Non-vacuity checked by swapping the two prose projection branches — the test fails; restored, it passes.

Decisions worth reviewing as decisions

  1. Block id derives from the FIRST item (work-block:${first.id}). Keying on the last item would remount the block on every streamed append and discard the reader's disclosure choice.

  2. Maximal runs of consecutive work items, not "everything between prompt and answer." When a non-work row (permission gate, error, mid-turn plan update) lands inside the work, the span reading would have to lift that row out of position. Splitting keeps every row where it happened — which matters most for exactly those rows.

  3. Summary segments are expanded back to leaf tool rows inside the block, so the reader never faces two collapsed layers. The block is the one grouping in this variant.

  4. The final answer is identified positionally (last assistant message), not by liveness, so block membership does not reshuffle at turn completion.

  5. formatWorkBlockSummaryLabel departs from berd by appending · N failed. A bare step count is the one thing that leaves a reader unable to tell a clean run from a broken one.

  6. isActive accepts two evidence sources — a step reporting itself running, and the list's streamingItemId hint. Either alone leaves a gap: a streaming thought carries no tool status, and a tool left executing after an observer-stream drop would pin the block open forever.

  7. bg-background, not berd's literal bg-card, for the bullet mask. Same rule, different surface: in berd the transcript sits on a card, in Buzz it sits on the drawer's bg-background. The two tokens are not interchangeable here — in Buzz Dark the drawer sits inside [data-buzz-content-surface], which locally overrides --background to --buzz-content-dark while --card keeps the theme value. Measured in a seeded browser, bg-card paints the bullet rgb(36,41,46) over a rgb(26,26,26) drawer: a visible disc of the wrong shade, which is exactly the BOT-1599 failure berd's note warns about.

  8. Interim notes and relay posts are suppressed on THIS side, via a dedicated prose body and an explicit useIsInsideWorkBlockRail signal, rather than by reaching into the message presenter — so style(desktop): bring the conversation variant closer to berd's recipes #6720 keeps one rule for what a message looks like. Two different routes reach the same wrong result: an interim note is an assistant message (style(desktop): bring the conversation variant closer to berd's recipes #6720 would give it a 20px avatar + name identity row), and a relay messages send step is a tool call that merely classifies as renderClass: "message" (which routes it to a 28px avatar + speech bubble + delivery receipt). Either one nested in a muted rail step reads as the agent replying inside its own work. The signal defaults to false, so the other two variants cannot observe it.

  9. The signal is presentation, not variant. conversation alone is not the condition — the same relay step rendered outside a block in that variant should keep its bubble, and a test pins that half of the branch so suppressing it everywhere cannot pass.

ConversationThought removal

Per ss-core-02's sequencing ruling (b) and ss-dev-01's handoff, the ConversationThought branch of activityRenderClasses/ThoughtActivity.tsx is deleted in this PR, because this is the commit where the rail starts rendering thinking as a row — so no commit ever leaves focus mode with reasoning invisible. The default/compactPreview thought path is untouched. The four conversation-variant thought tests keyed to the old <details> are deleted rather than adapted, since the element they assert no longer exists on that path.

Files

Added:

  • agentSessionWorkBlockGrouping.tsgroupConversationWorkBlocks, conversationSegmentsForBlock, projectWorkBlockEntries, summarizeWorkBlock, formatWorkBlockSummaryLabel, formatPreviousStepsLabel, windowWorkBlockEntries, WORK_BLOCK_LIVE_WINDOW_SIZE = 3, and the WorkBlockItem / WorkBlockEntry types
  • AgentSessionWorkBlock.tsx — the rail UI + AgentSessionWorkBlockSegment
  • agentSessionWorkBlockGrouping.test.mjs
  • AgentSessionWorkBlockTestRig.mjs (311) — shared jsdom lifecycle, item fixtures and renderBlock for the two work-block suites
  • AgentSessionWorkBlock.test.mjs (352) — live, finished, fold animation, reader choice, rail glyph states
  • AgentSessionWorkBlock.orphaned.test.mjs (528) — orphaned work, per-kind rail presentation, streaming re-render cost

Modified: AgentSessionTranscriptList.tsx (variant branch, work-block segment kind), ThoughtActivity.tsx, AgentSessionTranscriptList.conversation.test.mjs, AgentSessionTranscriptList.conversationHarness.mjs (dead-export prune), agentSessionTranscriptContext.ts (the rail presentation signal), AgentSessionToolItem/ToolItem.tsx (honours it), agentSessionConversationMeta.ts.

Deleted as dead code, each with a comment or test recording why:

  • shared/hooks/useControlledDisclosure.ts + test — the block's trigger is a <button>, so there is no browser toggle echo to guard against and the hook had no remaining consumer.
  • thoughtDurationSecondsById, formatThoughtDisclosureLabel, elapsedSeconds + ~200 lines of tests that only tested themselves. ConversationThought was their only reader, and this PR deletes it. A bug had been reported in that code; fixing dead code would have been worse than removing it.

Verification

Gates at 036bd38c3: desktop suite 5487 passing / 0 failing (81 suites), tsc --noEmit clean, pnpm check at main's exact 4-finding baseline (2 warnings + 2 infos, all pre-existing, checked against main rather than assumed), px-text / pubkey-truncation / file-size gates clean, git diff --check clean. Focused suites: grouping + conversation-meta 39/39, the two work-block suites 30/30, conversation + chrome 21/21. CI on this head: 14 pass, 9 skipped, 0 failing, mergeStateStatus: CLEAN.

Eleven runtime mutants, each run in isolation, all caught: interim note falling through to the tool kind (4 failures), toolEntryState ordering failed-before-running (1), note through the message presenter (2), note given a wrench (1), bullet tinted red (1), prose muted (1), memo keyed on the freshly-projected entry object (2 — the actual bug I hit), streaming hint forced null (4), summary segments not expanded when finding the tail (1), rail bubble suppression removed (the relay-step test stops passing), and swapping the two prose projection branches (the [kind, item.type] pairing assertion fails). Plus the three compile-time mutants in the entry-type table above. Tree restored and re-verified afterward.

The fold animation is asserted in a real browser, not just at its end states — the preview spec samples the panel height per frame while it closes and requires at least one height strictly between full and zero. Re-run against this head's production tree: fold heights: 245.5 -> 0 via 41 samples, passing. Non-vacuity re-confirmed at this head by setting COLLAPSE_TRANSITION.duration to 0: the run fails with "the fold must pass through intermediate heights — a details element would jump straight to 0". A <details> element cannot animate height and fails the same way. The reduced-motion endpoint is covered in the same spec.

One honest note on coverage: mutating the echo guard revealed that a block-level echo test I had written was vacuous — the block's disclosure is a <button>, not <details>, so there is no programmatic toggle to echo. The guard and its hook were deleted as dead code rather than kept with a passing-but-empty test.

Screenshots

Seeded through the real __BUZZ_E2E_SEED_OBSERVER_EVENTS__ observer-frame path. No production caller passes variant="conversation" yet — the cover drawer that pins it is ss-dev-00's separate slice — so the variant was pinned in a throwaway worktree with Slice A cherry-picked to capture these. The work block is not reachable in a build until that slice lands alongside this one. The preview spec is not committed to this branch.

Provenance of the browser numbers, stated exactly because a previous revision of this description got it wrong: the preview tree is not a checkout of this branch (it carries the conversation variant pin and unrelated main drift), so "same head" is the wrong claim to make about it. What is checked instead is that the five production files this PR touches are byte-identical there to their blobs at 036bd38c3AgentSessionWorkBlock.tsx 27055c199, agentSessionWorkBlockGrouping.ts 9d076f84f, agentSessionConversationMeta.ts 645622598, agentSessionTranscriptContext.ts aab5ed1d2, MessageActivity.tsx 9b1f8d588 — verified by git hash-object against git rev-parse 036bd38c3:<path> in the same shell as the run. The earlier attribution to a05347dd0 was doubly wrong: that head predates the orphan gate entirely (liveTurnId does not appear in its grouping or meta blob), and the preview tree was carrying MessageActivity.tsx at the pre-#6720 blob 6b42a637a rather than either head's. The 3-spec run is green on the corrected tree.

Folded (7 steps · 1 failed) Open rail

The rail at review size — every step reads the same way, including the relay post (Sent Confirmed the plural/singular mismatch…), which earlier rendered as a speech bubble with an avatar and delivery receipt:

Measured bullet/surface colours, both themes:

Theme bullet drawer surface spine
github-light rgb(255,255,255) rgb(255,255,255) rgb(229,229,230)
buzz-dark rgb(26,26,26) rgb(26,26,26) rgb(64,69,74)

Exact match in both, and asserted (expect(bullet).toBe(drawer)) rather than eyeballed, so the BOT-1599 masking contract holds. Re-run against this head's production tree in buzz-dark: {"bullet":"rgb(26, 26, 26)","drawer":"rgb(26, 26, 26)","spine":"rgb(64, 69, 74)"}, passing. Non-vacuity re-confirmed at this head by swapping the bullet to bg-card: Expected: "rgb(26, 26, 26)" / Received: "rgb(36, 41, 46)" — exactly the wrong-shade disc berd's note warns about.

Screenshot hosting: these are raw.githubusercontent.com URLs from scripts/post-screenshots.sh. The previous buzz.block.builderlab.xyz/media/... links returned 401 to GitHub's anonymous camo proxy and rendered broken for anyone reading on GitHub.

Orphaned running steps

The independent bug pass reproduced a C-specific wrong state: reopened history with an executing/pending tool and no live session stayed expanded, pulsed indefinitely, and engaged the live window. Fixed in fe57b7ac7. AgentSessionTranscriptTurnMeta now carries the channel-scoped live turn id; in-flight tool entries are running only when their item turn matches it. Abandoned in-flight statuses project to neutral settled for policy, so history folds to N steps without inventing a failure; recorded failures remain failures. A matching live turn remains active, and an agent live on a later turn cannot resurrect an earlier step. The existing outside-block activity presenter path is unchanged.

Verification at 036bd38c3: focused grouping + conversation-meta tests 39/39, the orphan cases in AgentSessionWorkBlock.orphaned.test.mjs pass, full desktop suite 5487/0, tsc --noEmit clean, pnpm check at baseline, and all push hooks green (including the full desktop test hook).

Seven mutants pin this gate, each run in isolation against the unit suite: gate removed / always running (4 failures), gate inverted so executing is never running (5 — the fix that would have "passed" the report while breaking live work), truthiness instead of turn ownership (1), item.turnId === liveTurnId without the null guard (1), abandoned step reported failed instead of settled (4), liveTurnId forced null in the meta builder (2), lastTurnId reading only the final block (1).

Measured in a real browser as well, since the failure mode is presentational: an agent panic mid-step folds to 3 steps with 0 infinite animations and rail states ["settled","settled","settled"]; the same events without the panic keep the rail open at ["settled","settled","running"] with exactly 1 infinite animation. Both directions are mutation-checked — removing the liveness comparison fails the orphan scenario and passes the live one, inverting it does the reverse — so no one-sided fix satisfies both.

The originally-flagged file, agentSessionToolRunSummary.ts, was deleted by this PR's retarget and does not exist at this head; the gate landed at the grouping/projection seam (agentSessionWorkBlockGrouping.ts, agentSessionConversationMeta.ts) instead. See #6536 (comment).

Test file sizes

AgentSessionWorkBlock.test.mjs first landed at 1,146 lines. Today's desktop ratchet passes it only because the script roots allowlist .ts/.tsx and skip .mjs — the gap #6736 closes. With that rule table cherry-picked it is a real violation, and since allowedLineCount grandfathers an over-ceiling base, whatever count C merges with becomes that file's permanent ceiling. So the suite is split here rather than after: rig 311, live/finished 352, orphaned 528; conversation 366, chrome 274, harness 571 — all under 1,000.

The split preserves behaviour, checked rather than assumed: the 30 test titles across the two files are an exact set match with the 30 in the single file, and every body is byte-identical apart from one call. prefersReducedMotion had to become setPrefersReducedMotion(value) because ESM bindings are read-only in importers; stubbing that setter to a no-op fails exactly one test, the reduced-motion one, so it is not a flag nothing sets.

Ratchet with #6736's rules cherry-picked, each run in the same shell as git rev-parse HEAD: base fd2e01799 exit 0, base merge-base(origin/main) = db5617dd1 exit 0. Negative control at the pre-split tree, same rules and base: exit 1, AgentSessionWorkBlock.test.mjs: new -> 1146 lines (allowed 1000).

@baxen
baxen requested a review from a team as a code owner August 22, 2026 01:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 643b310690

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/src/features/agents/ui/agentSessionToolRunSummary.ts Outdated
@baxen

baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Latest revision and validation:

  • Pushed 74b901527a8b8d8b1740693f55f64758237e89bb (origin matches).
  • Full desktop pnpm test: 5414/5414 pass.
  • pnpm typecheck: clean.
  • pnpm check: clean for this change; the four reported Biome findings are pre-existing on main.
  • pnpm check:file-sizes: clean; pre-push hooks green.
  • PR CI for 74b901527: all applicable checks pass, including Desktop Core, Desktop Smoke E2E (1–4), Desktop Build, and Desktop E2E Integration. Inapplicable lanes are skipped.

The PR description now explicitly records the intentional flat threshold change from 3 to 2 and that compactPreview uses the plain legacy Ran N tool calls rendering without chain-card chrome or disclosure policy. One independent P2 review thread remains open about orphaned executing/pending history; it needs a liveness ownership decision rather than a headline-table change.

@ss-core-02

@baxen

baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Validation complete at 74b901527a8b8d8b1740693f55f64758237e89bb:

  • Full desktop pnpm test: 5414/5414 pass.
  • pnpm typecheck: clean.
  • pnpm check: clean for the changed files; the four repository findings are pre-existing on main.
  • pnpm check:file-sizes: clean; pre-push hooks green.
  • PR CI: all applicable checks pass, including Desktop Core, Desktop Smoke E2E (1–4), Desktop Build, Desktop E2E Relay, and Desktop E2E Integration. Inapplicable lanes are skipped.

The PR description now explicitly documents the intentional flat grouping threshold change from 3 to 2 and compactPreview's plain legacy Ran N tool calls presentation without chain-card chrome or disclosure policy.

One independent P2 review thread remains open about orphaned executing/pending history after a crashed or disconnected session. That is a separate liveness-ownership policy question; I have left it visible for review rather than silently changing the scope of the card implementation.

@ss-core-02

@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from 74b9015 to e875102 Compare August 24, 2026 19:52
@baxen baxen changed the title feat(desktop): collapse consecutive tool steps into one tool-chain card feat(desktop): fold focus-mode agent work into one transcript block Aug 24, 2026
@baxen
baxen changed the base branch from main to ss-dev-01/conversation-variant August 24, 2026 20:06
@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from e875102 to a05347d Compare August 24, 2026 21:47
@baxen

baxen commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Revision: rebased onto #6720, and a bug the browser caught

Head: a05347dd0. Rebased onto ss-dev-01/berd-restyle (#6720) per core-02's revised B → #6720 → C ordering; base retargeted to match.

Relay posts are plain rail steps now

Caught by looking at the rendered preview, not by a unit test — the JSDOM suite was green while the rail was visibly wrong.

A buzz messages send step classifies as renderClass: "message", so it rendered through CompactMessageSummary: 28px avatar, bordered speech bubble, timestamp, delivery-receipt button. Correct in the activity feed, where a posted message is a destination to open. On a muted rail it made the agent appear to reply in the middle of its own work.

This is the same failure core-02 flagged for interim notes, reached by a different route: there the item is an assistant message, here it is a tool call that merely classifies as one. Fixed with an explicit presentation signal (useIsInsideWorkBlockRail, default false) rather than by reading the transcript variant — conversation alone is not the condition, since the same relay step outside a block should keep its bubble. Both halves of that branch are pinned by tests, so suppressing the bubble everywhere cannot pass. Defaulting to false keeps default/compactPreview markup byte-identical.

Before / after, same seeded turn:

Before — bubble nested in the rail After — a step like any other
(see the Confirmed the plural/singular mismatch… bubble with avatar + delivery checks in the prior comment's rail shot) work-block-05-rail-crop

Fold animation now asserted in a browser

Per the quality list: the fold must animate, and a unit test can only see end states. The preview spec samples the panel height per frame while it closes and asserts at least one height strictly between full and zero — which a <details>-style jump fails:

[work-block] fold heights: 245.5 -> 0 via 41 samples

Mutation-checked: setting COLLAPSE_TRANSITION.duration to 0 makes it fail with "the fold must pass through intermediate heights". The reduced-motion endpoint is covered in the same spec — the block still ends folded, without settle frames.

Full states

Folded (7 steps · 1 failed) Open rail
work-block-03-folded work-block-04-open-rail

Plan sits as a sibling after the block; the failed step is inside it and named in the folded line; the answer's prose and fenced code are unaffected.

Verification at a05347dd0

  • Desktop suite 5469 passing / 0 failing (5467 + the two new relay tests), tsc --noEmit clean, pnpm check at main's baseline (2 warnings + 2 infos, all pre-existing), px-text / pubkey-truncation / file-size gates clean.
  • Screenshots captured from the seeded e2e session on the composed tree (Slice A cherry-picked in a throwaway worktree, since the cover drawer that pins variant="conversation" lives in dev-00's slice). The preview spec is not committed to this branch.
  • Screenshot URLs re-hosted through scripts/post-screenshots.sh; the old buzz.block.builderlab.xyz/media/... links in the PR body returned 401 to GitHub's camo proxy and are replaced.

work-block-01-opened

work-block-02-prompt-and-thinking

baxen pushed a commit that referenced this pull request Aug 24, 2026
@baxen
baxen changed the base branch from ss-dev-01/conversation-variant to ss-dev-01/berd-restyle August 24, 2026 21:49
@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from a05347d to 9c2900e Compare August 24, 2026 22:47
baxen added a commit that referenced this pull request Aug 25, 2026
A tool item's `executing`/`pending` status is written when the step starts
and never revised if the agent dies first, so an abandoned step keeps it
forever. On the activity feed that was a stale row label. In the work
block this commit introduces it is a MODE: one `running` entry makes
`summarizeWorkBlock` report `isActive`, which suppresses the folded
summary line, holds the rail open and pulses a bullet. Scrolling back to
a crashed turn therefore showed the reader live work indefinitely
(Codex on #6536, discussion_r3848214880 — not stale, and worse here than
on the card it was filed against).

Status alone cannot answer "is this step happening?", so the block is
given the missing half: `AgentSessionTranscriptTurnMeta.liveTurnId`,
published by the list from the same display blocks it already reads for
`streamingItemId`. `toolEntryState` treats `executing`/`pending` as
`running` only when a session owns that step's turn.

A turn id, not a boolean. "Some turn is live" is not the question: an
agent that crashed during turn 1 and is now working on turn 2 IS live,
yet turn 1's abandoned step is no more running than before — a global
flag would keep it spinning in exactly the case a restarted agent makes
common. The comparison is explicit (`liveTurnId !== null && ...`) so an
item with no turn id cannot match a null live turn by `null === null`.

An abandoned step is reported as `settled`, not as a new state and not as
`failed`, per core-02:

- We do not know it failed — only that nobody finished it — so it must
  not count toward the folded line's `N failed`. It folds to a neutral
  `N steps`.
- No third entry state and no new glyph. It renders as the neutral step
  it is, with the same muted detail the list already shows for it outside
  a block. A visible "interrupted" marker would be a design addition,
  not a bug fix.

`liveTurnId` is read from the display blocks rather than from the
active-turn store, because the store's turn ids and the transcript's are
populated by different paths and a mismatch would silently gate every
step off — the mirror image of this bug. It is the last *turn* block, not
the last block: a compaction notice arrives as a `single` block after the
turn it belongs to, so reading the final block's kind would report no
live turn at all.

`projectWorkBlockEntries` takes the option as a required argument rather
than defaulting it, so a future caller that has not thought about
liveness cannot silently get the spins-forever behaviour back.

Non-vacuous by mutation, each mutant run in isolation against the unit
suite:

- gate removed (always `running`): 4 failures.
- gate inverted (`executing` never `running`): 5 — this is the fix that
  would have "passed" the bug report while breaking live work.
- truthiness instead of turn ownership (any live turn resurrects an old
  abandoned step): 1.
- `item.turnId === liveTurnId` without the null guard: 1.
- abandoned step reported `failed` instead of `settled`: 4.
- `liveTurnId` forced null in the meta builder: 2.
- `lastTurnId` reading only the final block: 1.

Measured in a real browser as well, not only JSDOM, because the failure
mode is presentational and animation-dependent — a unit test can assert
class names while the rendered rail is still wrong (the interim-note and
relay-send bugs on this branch were both invisible to the unit suite).
Two seeded scenarios in the preview harness, driving the real component
through the cover drawer under Buzz Dark:

- agent panic mid-step (the terminal `crates/buzz-acp` actually emits):
  folded label `3 steps`, rail collapses to 0 rows, `getAnimations`
  reports 0 infinite animations in the block, and opened the rail reads
  `["settled","settled","settled"]`.
- the same events with no panic: no folded line, rail open, states
  `["settled","settled","running"]`, and exactly 1 infinite animation —
  the running bullet really does pulse.

Both browser scenarios were mutation-checked too: removing the gate fails
the first and passes the second, inverting it fails the second and passes
the first, so neither can be satisfied by a one-sided fix. (Those two
specs are not in this commit — they need Slice A's `conversation` variant
pin to be reachable, which is not on this branch.)

The row outside a block is untouched: `buildCompactToolSummary` still
derives its own `running` from status, and no file under
`activityRenderClasses/` or `agentSessionToolSummary.ts` is modified here.

Verified at this tree: full desktop suite 5486 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing).

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from 440a89d to 9fb04cf Compare August 25, 2026 01:09
ss-dev-02 and others added 2 commits August 24, 2026 18:12
Focus mode gave every thought and tool step its own row and its own
disclosure, so a turn that did real work scrolled its answer off screen
behind a stack of chrome. This adopts berd's shipping transcript model:
within a turn, everything between the prompt and the answer that is
thinking, a tool step, or an interim agent note becomes one "work block"
— a thin rail of small glyph bullets that folds to a single "N steps"
line when the work is done.

Grouping is variant-aware AT THE LIST BOUNDARY rather than inside the
grouping module. `TranscriptDisplayBlockView` runs the additive
work-block transform only for `conversation`, so `default` and
`compactPreview` walk main's segments on the identical code path they
always did. That makes Slice B's byte-for-byte baseline fixture hold BY
CONSTRUCTION rather than by assertion, which is the property worth having
here: no future edit to the block can regress the other two variants
without first moving this branch.

Behaviour, mirroring berd:

- Live: the block is open and the rail IS the status, so there is no
  header line to restate it. The last three steps show in true arrival
  order; older ones go behind an "N previous steps" disclosure at the top
  of the rail, so a long run cannot push the answer out of view.
- Finished: folds to "N steps" with a chevron that rotates on open.
- The fold ANIMATES. `<details>` cannot do this — its content is either
  laid out or not, with no intermediate height — so the collapse would
  snap. A block that was live when it mounted renders open for a paint
  and then settles closed, giving the height tween a start state that was
  actually painted; a block already finished on mount (scrollback) never
  had a rail on screen and closes immediately, so the animation stays
  meaningful instead of firing on every mount.
- Reader choice wins: once the reader toggles a block, policy stops
  opening and closing it.

One deliberate departure from berd, per core-02: a finished block holding
a failure folds to "N steps · 1 failed". A bare count is the one thing a
reader cannot distinguish a clean run from a broken one by, and a fold
that hides a failure behind a neutral number invites them not to open it.
The rail bullet itself stays muted, per berd — failure is a glyph shape,
not a colour, so one bad step does not read as an alarm across the run.

Items are projected ONCE into rail entries, and the glyph, the body and
the folded line's counts all read that projection. Two independent
classifications of the same item is precisely how the headline and the
chain eligibility drifted apart on the abandoned tool-chain card, so both
render sites are exhaustive switches over the entry and neither asks
`item.type` again.

The projection is closed in the TYPE SYSTEM, not only by convention. An
earlier revision made the entry a product of independent fields
(`{ item, kind, state }`) with a catch-all `return "tool"`, which left two
things wrong that no test could see: a future `TranscriptItem` variant
would silently wear a wrench, and impossible pairs like
`{ kind: "note", item: <thought> }` stayed representable — so the body
switch still had to re-check `item.type` and render `""` on a mismatch it
could not otherwise handle. Meaning was therefore still derived in two
places.

Now:

- `WorkBlockItem` is the closed union of items a block admits, and
  `isWorkItem` is a type guard, so the membership decision is made once
  and every later stage receives the narrowed type. `admittedWorkItems`
  returns the narrowed array rather than a boolean because an `.every()`
  guard cannot narrow the array it tested.
- `WorkBlockEntry` is a discriminated union pairing each kind with its own
  item type, and fixing `state: "settled"` on the prose kinds. Both classes
  of impossible entry are now unrepresentable rather than defended against.
- `projectWorkBlockEntry` switches exhaustively over `WorkBlockItem` with
  no default, so admitting a new item type without deciding how it renders
  is a compile error (`TS2366: Function lacks ending return statement`),
  not a wrench.
- The body switch takes the whole entry, so narrowing on `kind` narrows
  `item` too. The `item.type === "thought" ? item.text : ""` fallbacks are
  gone because there is no longer a mismatch to fall back from.

Verified by compiling three mutants, each of which now fails `tsc` where
before it type-checked: admitting `plan` to `WorkBlockItem` without a
projection case (TS2366), projecting a thought as a note (TS2322 on
`item`), and giving a thought `state: "failed"` (TS2322 on `state`). The
runtime kind/item pairing is also asserted in
`agentSessionWorkBlockGrouping.test.mjs`, because types are stripped at
runtime and swapping the two prose branches by hand is the easy mistake —
that mutant fails the test.

The projection also fixes an ordering bug the old split invited: a tool
carrying a stale `isError` from a retry while the new attempt executes
reads as `running`, not `failed`, so a live block cannot fold its own
count to "N steps · 1 failed" while the work is still in flight.

**Interim notes suppress the identity row.** #6720 gives every
conversation-variant assistant message a 20px avatar + name row, which is
right for the turn's answer. A rail note is the same item type, so
routing it through that presenter would render a fully attributed agent
turn nested inside a muted step row — the agent apparently replying twice,
once inside the work it was doing. Notes render through a dedicated rail
prose body instead, keeping markdown and the focus code-block recipe by
providing the same `CodeBlockVariantContext` value the presenter would.
Done on this side rather than by reaching into #6720, so that PR keeps one
rule for what a message looks like. Notes share the thought's speech
bubble, matching berd's `progress` entry: both are the agent talking.

- `useControlledDisclosure` is deleted rather than reused. Its entire
  reason to exist was the `<details>` echo trap — `<details>` fires
  `toggle` for programmatic `open` changes indistinguishably from clicks,
  so a policy-driven open echoes back looking like reader intent. This
  block's trigger is a `<button>`, where the only thing that can call the
  handler is a real click. Keeping the guard would have been dead code
  masquerading as load-bearing. Nothing else in the tree imported it.
- berd brightens rail prose with `usePrimaryText={open}`; here the
  brightening is unconditional. A closed block unmounts its rows rather
  than dimming them, so there is no state in which rail prose is on
  screen and not in an open block — the flag's false branch would be
  unreachable. A test records that reasoning so the divergence is not
  mistaken for an oversight.

dev-01 flagged that `turnSegmentItems` does not know `work-block`, so a
thought followed only by block content would never settle its duration.
Tracing it: the meta is built from pre-transform display blocks, so no
`work-block` segment can reach that function — the reported bug cannot
fire. But the trace turned up something worse. `ConversationThought` was
the only reader of `thoughtDurationSecondsById` and
`formatThoughtDisclosureLabel`, and this commit deletes it, leaving ~100
lines of production code and ~200 lines of tests that only tested each
other. Fixing a bug in code with no reader would have preserved the
illusion that focus mode still shows "Thought for Ns" somewhere.

So the duration map, its label formatter and `elapsedSeconds` are gone.
`AgentSessionTranscriptTurnMeta` narrows to the one field that still has
a consumer: `streamingItemId`, which the work block needs because a
thought or note streaming in carries no status of its own. Its tests are
rewritten around what that hint must actually get right — the tail of a
live turn, skipping setup, expanding a summary segment to its last leaf,
and reporting nothing at all when the turn is idle.

Three further notes on translation rather than transcription:

- berd's bullet masks the spine with `bg-card` and its BOT-1599 note
  warns off `bg-background`. The rule is "mask with the surface the
  transcript is drawn on"; in Buzz that surface is the cover drawer,
  which is literally `bg-background`. The two tokens are NOT
  interchangeable here: `[data-buzz-content-surface]` locally overrides
  `--background` to `--buzz-content-dark` while `--card` keeps the theme
  value, so in Buzz Dark `bg-card` paints the bullet rgb(36,41,46) over an
  rgb(26,26,26) drawer — the exact BOT-1599 failure. Light mode matches
  under either class, so light-mode evidence alone would not have caught
  it. Copying the class would have followed the letter of berd's note
  against its point.
- Reduced motion is read via `matchMedia` — the way `TerminalSubstrate`
  reads it — not motion's `useReducedMotion`, which resolves the query
  once per process and caches it. That cache made the preference
  untestable (the assertion turned on module load order, not on the
  setting) and ignored a mid-session change.
- The memo takes the entry SPREAD into props, not the entry object, and
  its boundary is the step BODY rather than the whole row. The projection
  is rebuilt whenever the item array changes, so entry objects are fresh
  on every append and a memo keyed on one would never hit; spread, the
  compared props are `item` (reference-stable) plus two strings. Spreading
  also keeps the union intact, so the body switch still narrows `item`
  from `kind`. The row stays outside because the glyph depends on
  `isLast`, which changes for the previous last row on every append.

`ConversationThought` is deleted here rather than in dev-01's restyle,
per core-02's sequencing ruling: this is the commit that replaces it, so
reasoning stays visible in focus mode at every commit. Its four
disclosure-keyed tests are deleted rather than adapted — they assert a
`<details>` that no longer exists on that path. `default`/
`compactPreview` thought rendering is untouched.

Non-vacuous by mutation testing, each mutant run in isolation:

- note falls through to the tool kind: 4 failures.
- note routed through the message presenter: 2. Note given the wrench: 1.
- `entryState` checking failure before running: 1.
- rail bullet tinted on failure: 1. Prose muted instead of primary: 1.
- memo keyed on the projected entry object: 2 — this is the bug the
  projection actually introduced, caught by the pre-existing streaming
  cost tests before it shipped.
- streaming hint forced to null: 4. Summary segments not expanded when
  finding the tail: 1.
- windowing keyed off `open` instead of the reader's choice: 3. Policy
  already holds live blocks open, so this switches windowing off in
  exactly the case it exists for.
- reduced-motion preference forced false: 1.
- rail bubble suppression removed (the bug above): the relay-step test
  stops passing.

An earlier mutant also caught a test lying: the block-level "echo" test
passed with the guard removed, because the trigger is a `<button>` and
nothing was listening for `toggle` at all. It now asserts the structural
reason the trap cannot apply plus a repeated fold→reopen→fold cycle,
which is what a recorded echo would actually have disabled.

Relay posts are plain rail steps. A `buzz messages send` step classifies
as `renderClass: "message"`, so it renders through `CompactMessageSummary`
— 28px avatar, bordered speech bubble, timestamp, delivery-receipt
button. Correct in the activity feed, where a posted message is a
destination to open; on a muted rail it makes the agent appear to reply
in the middle of its own work. This is the same failure the interim-note
case avoids, reached by a different route: there the item IS an assistant
message, here it is a tool call that merely classifies as one. Caught by
looking at the seeded browser preview, not by a unit test — the JSDOM
suite was green while the rendered rail was wrong.

Suppressed with an explicit presentation signal
(`useIsInsideWorkBlockRail`, default false) rather than by reading the
transcript variant, because `conversation` alone is not the condition:
the same relay step rendered OUTSIDE a block in that variant should keep
its bubble. Both halves of that branch are now pinned by tests, so
suppressing the bubble everywhere cannot pass. Defaulting to false keeps
`default`/`compactPreview` markup byte-identical.

Rebased onto #6720 at `e8709554a` per core-02's B → #6720 → C ordering.

Verified at this tree: full desktop suite 5472 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing), px-text/pubkey-truncation/
file-size gates clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
A tool item's `executing`/`pending` status is written when the step starts
and never revised if the agent dies first, so an abandoned step keeps it
forever. On the activity feed that was a stale row label. In the work
block this commit introduces it is a MODE: one `running` entry makes
`summarizeWorkBlock` report `isActive`, which suppresses the folded
summary line, holds the rail open and pulses a bullet. Scrolling back to
a crashed turn therefore showed the reader live work indefinitely
(Codex on #6536, discussion_r3848214880 — not stale, and worse here than
on the card it was filed against).

Status alone cannot answer "is this step happening?", so the block is
given the missing half: `AgentSessionTranscriptTurnMeta.liveTurnId`,
published by the list from the same display blocks it already reads for
`streamingItemId`. `toolEntryState` treats `executing`/`pending` as
`running` only when a session owns that step's turn.

A turn id, not a boolean. "Some turn is live" is not the question: an
agent that crashed during turn 1 and is now working on turn 2 IS live,
yet turn 1's abandoned step is no more running than before — a global
flag would keep it spinning in exactly the case a restarted agent makes
common. The comparison is explicit (`liveTurnId !== null && ...`) so an
item with no turn id cannot match a null live turn by `null === null`.

An abandoned step is reported as `settled`, not as a new state and not as
`failed`, per core-02:

- We do not know it failed — only that nobody finished it — so it must
  not count toward the folded line's `N failed`. It folds to a neutral
  `N steps`.
- No third entry state and no new glyph. It renders as the neutral step
  it is, with the same muted detail the list already shows for it outside
  a block. A visible "interrupted" marker would be a design addition,
  not a bug fix.

`liveTurnId` is read from the display blocks rather than from the
active-turn store, because the store's turn ids and the transcript's are
populated by different paths and a mismatch would silently gate every
step off — the mirror image of this bug. It is the last *turn* block, not
the last block: a compaction notice arrives as a `single` block after the
turn it belongs to, so reading the final block's kind would report no
live turn at all.

`projectWorkBlockEntries` takes the option as a required argument rather
than defaulting it, so a future caller that has not thought about
liveness cannot silently get the spins-forever behaviour back.

Non-vacuous by mutation, each mutant run in isolation against the unit
suite:

- gate removed (always `running`): 4 failures.
- gate inverted (`executing` never `running`): 5 — this is the fix that
  would have "passed" the bug report while breaking live work.
- truthiness instead of turn ownership (any live turn resurrects an old
  abandoned step): 1.
- `item.turnId === liveTurnId` without the null guard: 1.
- abandoned step reported `failed` instead of `settled`: 4.
- `liveTurnId` forced null in the meta builder: 2.
- `lastTurnId` reading only the final block: 1.

Measured in a real browser as well, not only JSDOM, because the failure
mode is presentational and animation-dependent — a unit test can assert
class names while the rendered rail is still wrong (the interim-note and
relay-send bugs on this branch were both invisible to the unit suite).
Two seeded scenarios in the preview harness, driving the real component
through the cover drawer under Buzz Dark:

- agent panic mid-step (the terminal `crates/buzz-acp` actually emits):
  folded label `3 steps`, rail collapses to 0 rows, `getAnimations`
  reports 0 infinite animations in the block, and opened the rail reads
  `["settled","settled","settled"]`.
- the same events with no panic: no folded line, rail open, states
  `["settled","settled","running"]`, and exactly 1 infinite animation —
  the running bullet really does pulse.

Both browser scenarios were mutation-checked too: removing the gate fails
the first and passes the second, inverting it fails the second and passes
the first, so neither can be satisfied by a one-sided fix. (Those two
specs are not in this commit — they need Slice A's `conversation` variant
pin to be reachable, which is not on this branch.)

The row outside a block is untouched: `buildCompactToolSummary` still
derives its own `running` from status, and no file under
`activityRenderClasses/` or `agentSessionToolSummary.ts` is modified here.

Verified at this tree: full desktop suite 5486 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing).

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
baxen added a commit that referenced this pull request Aug 25, 2026
A tool item's `executing`/`pending` status is written when the step starts
and never revised if the agent dies first, so an abandoned step keeps it
forever. On the activity feed that was a stale row label. In the work
block this commit introduces it is a MODE: one `running` entry makes
`summarizeWorkBlock` report `isActive`, which suppresses the folded
summary line, holds the rail open and pulses a bullet. Scrolling back to
a crashed turn therefore showed the reader live work indefinitely
(Codex on #6536, discussion_r3848214880 — not stale, and worse here than
on the card it was filed against).

Status alone cannot answer "is this step happening?", so the block is
given the missing half: `AgentSessionTranscriptTurnMeta.liveTurnId`,
published by the list from the same display blocks it already reads for
`streamingItemId`. `toolEntryState` treats `executing`/`pending` as
`running` only when a session owns that step's turn.

A turn id, not a boolean. "Some turn is live" is not the question: an
agent that crashed during turn 1 and is now working on turn 2 IS live,
yet turn 1's abandoned step is no more running than before — a global
flag would keep it spinning in exactly the case a restarted agent makes
common. The comparison is explicit (`liveTurnId !== null && ...`) so an
item with no turn id cannot match a null live turn by `null === null`.

An abandoned step is reported as `settled`, not as a new state and not as
`failed`, per core-02:

- We do not know it failed — only that nobody finished it — so it must
  not count toward the folded line's `N failed`. It folds to a neutral
  `N steps`.
- No third entry state and no new glyph. It renders as the neutral step
  it is, with the same muted detail the list already shows for it outside
  a block. A visible "interrupted" marker would be a design addition,
  not a bug fix.

`liveTurnId` is read from the display blocks rather than from the
active-turn store, because the store's turn ids and the transcript's are
populated by different paths and a mismatch would silently gate every
step off — the mirror image of this bug. It is the last *turn* block, not
the last block: a compaction notice arrives as a `single` block after the
turn it belongs to, so reading the final block's kind would report no
live turn at all.

`projectWorkBlockEntries` takes the option as a required argument rather
than defaulting it, so a future caller that has not thought about
liveness cannot silently get the spins-forever behaviour back.

Non-vacuous by mutation, each mutant run in isolation against the unit
suite:

- gate removed (always `running`): 4 failures.
- gate inverted (`executing` never `running`): 5 — this is the fix that
  would have "passed" the bug report while breaking live work.
- truthiness instead of turn ownership (any live turn resurrects an old
  abandoned step): 1.
- `item.turnId === liveTurnId` without the null guard: 1.
- abandoned step reported `failed` instead of `settled`: 4.
- `liveTurnId` forced null in the meta builder: 2.
- `lastTurnId` reading only the final block: 1.

Measured in a real browser as well, not only JSDOM, because the failure
mode is presentational and animation-dependent — a unit test can assert
class names while the rendered rail is still wrong (the interim-note and
relay-send bugs on this branch were both invisible to the unit suite).
Two seeded scenarios in the preview harness, driving the real component
through the cover drawer under Buzz Dark:

- agent panic mid-step (the terminal `crates/buzz-acp` actually emits):
  folded label `3 steps`, rail collapses to 0 rows, `getAnimations`
  reports 0 infinite animations in the block, and opened the rail reads
  `["settled","settled","settled"]`.
- the same events with no panic: no folded line, rail open, states
  `["settled","settled","running"]`, and exactly 1 infinite animation —
  the running bullet really does pulse.

Both browser scenarios were mutation-checked too: removing the gate fails
the first and passes the second, inverting it fails the second and passes
the first, so neither can be satisfied by a one-sided fix. (Those two
specs are not in this commit — they need Slice A's `conversation` variant
pin to be reachable, which is not on this branch.)

The row outside a block is untouched: `buildCompactToolSummary` still
derives its own `running` from status, and no file under
`activityRenderClasses/` or `agentSessionToolSummary.ts` is modified here.

Verified at this tree: full desktop suite 5486 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing).

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from 9fb04cf to e9e2db4 Compare August 25, 2026 01:18
@baxen

baxen commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Final verification at e9e2db42b\n\nC is two commits on fd2e01799 (#6720 head), with the conversation test additions correctly re-homed into the split test layout. The branch and PR head match; the detached read-only review worktree is clean.\n\n- Focused grouping: 26/26\n- Conversation metadata: 13/13\n- Conversation list: 20/20\n- Conversation chrome: 9/9\n- Work-block component: 30/30, clean isolated process exit\n- Typecheck and pnpm check: clean / main baseline only\n- Full pre-push desktop lane: green\n- PR CI run 32797042479: all applicable checks passed, all inapplicable checks skipped\n\nThe Codex P2 thread has a single final reply with the rebased SHA. It remains open pending the C bug pass.

@baxen

baxen commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Rebased final head verified: e9e2db42babaf77a1e6feb3c4a167602bb4b93a0, exactly two commits on #6720 head fd2e017998c666ccd520a0b430a3db881e32401a. The conversation coverage is in the split test shape from #6720: the main variant contract remains in AgentSessionTranscriptList.conversation.test.mjs, while shared JSDOM/locale/render helpers are in AgentSessionTranscriptList.conversationHarness.mjs and identity/code chrome coverage is in AgentSessionTranscriptList.conversationChrome.test.mjs.\n\nCI run 32797042479 settled all applicable checks green (Desktop Core, four Smoke shards, macOS build, relay, both integration shards and aggregate, Desktop, DCO, policy guards); inapplicable lanes skipped. Local full pre-push desktop lane and focused suites were green; clean detached review tree confirmed. The Codex thread now has one final reply for this head and remains open pending the bug pass.

@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from e9e2db4 to fe57b7a Compare August 25, 2026 01:52
@baxen

baxen commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Final C head is fe57b7ac7bea016b9029b5a09682773c8b65b22b (fe57b7ac7), pushed by dev-02 and matching origin. It remains exactly two commits on #6720 head fd2e017998c666ccd520a0b430a3db881e32401a; the conversation test additions are in the split shape, with production files byte-identical to e9e2db42b.

I re-read the final conversation test file on this head and confirmed the coverage remains in the owning files: main conversation contract 12 tests / 366 lines; chrome 9 tests; shared harness 576 lines. The existing work-block, grouping, and metadata suites remain 30/30, 26/26, and 13/13. Full pre-push desktop test, typecheck, check, and push all passed. PR CI run 32797042479 settled all applicable checks green.

The over-ceiling check was run against #6736's .mjs rule table in a detached probe: AgentSessionWorkBlock.test.mjs is 1145 lines on this head and fails the new 1000-line .mjs ceiling (the current head therefore needs the requested post-bug-pass split before merge); AgentSessionTranscriptList.conversation.test.mjs is 366, AgentSessionTranscriptList.conversationChrome.test.mjs is 274, and AgentSessionTranscriptList.conversationHarness.mjs is 576. No production behavior changed in this push. The Codex thread is intentionally still open pending the bug pass.

@baxen

baxen commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

C's pushed head is fe57b7ac7bea016b9029b5a09682773c8b65b22b (fe57b7ac7), authored and pushed by dev-02. It is exactly two commits on #6720 head fd2e017998c666ccd520a0b430a3db881e32401a, with production files byte-identical to e9e2db42b.

The re-homed conversation test file is 366 lines; chrome is 274 and the shared harness is 576. The work-block test remains 1,145 lines and is intentionally the post-bug-pass split item, because the .mjs ratchet from #6736 is not yet in the branch. The branch was pushed with the full pre-push desktop test, typecheck, check, and push gates green. CI run for fe57b7ac7 is settling; the prior rebased run at e9e2db42b was fully green.

The Codex thread is still intentionally unresolved. It will get one final reply for fe57b7ac7 after the bug pass; no production changes are pending from this minimal test-shape resolution.

…harness

`AgentSessionWorkBlock.test.mjs` landed at 1,146 lines. Under today's rule
tables that passes, but only because the desktop ratchet's script roots
allowlist `.ts`/`.tsx` and silently skip `.mjs` — the gap #6736
(`ce1f1d427`) closes. Measured rather than assumed: with that rule table
cherry-picked, this file is the one remaining violation on C, and because
`allowedLineCount` grandfathers a base that already exceeds the max,
whatever count C lands with becomes the file's permanent ceiling on main.
1,146 is not a ceiling worth inheriting for a file that exists to cover a
single component.

Split on the seam the file already had, at `// -- Orphaned work --`, where
the subject changes from live-vs-finished *policy* to what an individual
*row* is:

- `AgentSessionWorkBlockTestRig.mjs` (311) — jsdom lifecycle, the item
  fixtures, `settle()`, `renderBlock()`.
- `AgentSessionWorkBlock.test.mjs` (352) — Live, Finished, fold animation,
  reader choice, rail glyph states.
- `AgentSessionWorkBlock.orphaned.test.mjs` (528) — orphaned work, the
  per-kind rail presentation, streaming re-render cost.

One rig, not a copy per file. The two suites run in separate processes
(node's runner is one process per file), so a second copy of the jsdom and
`matchMedia` setup could not *collide* — it would drift, and a drifted
ambient pin fails a fixture for a reason that has nothing to do with the
markup under test. That trap already cost this suite family two commits
(`2b7b0baf6`, `c91819706`).

The split forces one real behavioural change. `prefersReducedMotion` was
a module-level `let` the reduced-motion test assigned directly; ESM
bindings are read-only in importers, so once it lives in the rig that
assignment cannot work. It goes through `setPrefersReducedMotion(value)`,
with the rig's `afterEach` still resetting it. Proved non-vacuous by
stubbing the setter to a no-op: that fails exactly one test — the
reduced-motion one — and restoring it passes. Without that check the test
would have been asserting against a flag nothing sets, which is precisely
the way a split can silently disarm a test.

Behaviour is otherwise preserved, checked rather than assumed: the 30 test
titles across the two files are an exact set match with the 30 in the
single file, and every body is byte-identical apart from the setter call.

Harness prune, in the same revision because it is the same debt: with the
four `<details>` disclosure tests gone, `export const domWindow =
dom.window` had no consumer anywhere in `desktop/src` — it existed only
for `new domWindow.Event("toggle")`. Dropping just the `export` leaves an
unused local that Biome flags (`lint/correctness/noUnusedVariables`), so
the declaration and its now-false doc comment both go (576 -> 571).
`TRIGGER_TITLE` only loses its `export`; it is still used inside the
harness.

Ratchet with #6736's rules cherry-picked, run in the same shell as
`git rev-parse HEAD`:

- base `fd2e01799` (the current PR base, which is what CI resolves —
  `resolveBaseRef` returns `HEAD^1` under `GITHUB_ACTIONS`): exit 0.
- base `merge-base(origin/main)` = `db5617dd1`, which is what it resolves
  to locally and would resolve to if C retargets: exit 0.
- negative control at the pre-split tree, same rules, same base: exit 1,
  `AgentSessionWorkBlock.test.mjs: new -> 1146 lines (allowed 1000)`. The
  gate is measuring the thing this commit fixes.

Both suites green together, 30/30. `conversation` + `conversationChrome`
still green. `tsc --noEmit` clean, `pnpm check` findings identical to
main's baseline.

The split shape, the seam and the reduced-motion proof are dev-01's, handed
over as a verified patch (`OUTBOX/C_WORKBLOCK_SPLIT_HARNESS_PRUNE.patch`,
sha256 6cfbccc9…) rather than as a commit in this checkout. I read the
seam, reconstructed the tree from `fe57b7ac7` + that patch to confirm it
is exactly what was proposed, and re-ran the title/body comparison, the
setter mutant, both ratchet bases and the negative control here.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen force-pushed the ss-dev-02/tool-chain-cards branch from 9d39bd2 to 036bd38 Compare August 25, 2026 02:27
ss-dev-02 and others added 2 commits August 24, 2026 21:50
…ing live

Two reader-visible bugs in the focus-mode work block, both found reviewing
this PR rather than by a test.

**The running step's pulse ignored reduced motion.** `WorkBlockRailGlyph`
applied bare `animate-pulse`. `useWorkBlockMotionEnabled` skips the fold's
height animation, but it cannot reach a keyframe animation applied by a
utility class, and none of the app's 20 `prefers-reduced-motion: reduce`
blocks matches `.animate-pulse` — every one is scoped to a
`buzz-*`/`motion-*`/`t-skel-*` class. So a reader who asked for no motion got
an indefinite pulse. Now `motion-safe:animate-pulse`, which this repo's
Tailwind (4.3.0) compiles to the same declaration wrapped in
`@media (prefers-reduced-motion: no-preference)` — and which matches every
other pulse in this feature (`AgentStatusBadge`, `ManagedAgentRow`).

That swap also repaired three assertions it would otherwise have disarmed.
`motion-safe:animate-pulse` is a different class TOKEN, not `animate-pulse`
plus a modifier, so the suite's `.animate-pulse` selectors would have stopped
matching anything and passed unconditionally. Worse, they were already
vacuous: all three ran after `settleToStepCount(0)`, i.e. on a folded rail
with no rows, so "nothing pulses" held against a build where abandoned steps
pulse forever — the exact Codex finding this PR answers. They now assert on
rows that exist (expanded, or mid-settle) and match the exact class token, so
neither a substring nor a missing element can fake a pass.

**A finished block went live again between turns.** `lastTurnId` walked the
display blocks for the newest `turn` block, but a turn that has only emitted
setup lifecycle rows (`turn_started`, `session_resolved`) classifies to zero
segments and so produces no block at all. For the whole gap between
`turn_started` and the next turn's first renderable item, "newest turn with a
block" was therefore the turn that had already ENDED, and its own trailing
item became the streaming item: a settled 6-step block re-opened, dropped to
its last three steps behind a previous-steps disclosure, then folded back.
`turn_started` fires on every turn and the gap is real observer-stream
latency, so this was every turn, not an edge case.

Liveness now comes from the newest turn id in the item stream, which is why
`buildConversationTurnMeta` takes `items`. A trailing item is also only
reported as streaming when the live turn owns it — the same ownership rule
`toolEntryState` already applies to an `executing` status, for the same
reason: without it the newest block's tail holds a finished turn's block open
regardless of whose turn it is.

Tests, each mutation-checked in isolation so none of them is vacuous:

- pulse guarded, asserted under BOTH preference values (the class must not
  depend on it) — reverting to the bare class fails exactly that test;
  dropping the `running` guard fails the three retargeted negatives.
- the meta gap contract over the real 4-frame sequence built from a raw item
  stream through the real grouping, since the bug is precisely which turns do
  and do not produce a block — reverting either half of the fix fails it.
- the rendered consequence with a 6-step block (above the live window, where
  the symptom is loudest): folded summary and no rail across the gap.

Also fixed a fixture that named the wrong thing: "no turn at all" carried the
shared helper's default `turnId: "turn-1"`, so it was asserting that a turn
with no block reports no live turn — the opposite of the rule.

Full desktop suite 5491 passing / 0 failing, 81 suites; `tsc --noEmit` clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
…e hang

Two review corrections to the previous commit, neither changing production
behaviour.

**The gap test was pinning the wrong sequence.** Its middle frames put a
`session/new` card between `turn_started` and `session_resolved`. That card
renders as a trailing `single` block, which moves `streamingItemId` off the
previous turn's work block on its own — so those frames pass against the old
block-walking code and prove nothing. Measured over the revert mutation:

    SEQ A f1  turn_started only                  liveTurnId=turn-1 tail=tool:2  BUG
    SEQ A f2  +session_resolved, no session/new  liveTurnId=turn-1 tail=tool:2  BUG
    SEQ B f1  +session/new                       liveTurnId=turn-1 tail=null    masked
    SEQ B f2  +session/new +session_resolved     liveTurnId=turn-1 tail=null    masked

The plain sequence — no session restart, which is the overwhelmingly common
path — now leads the frame list, and the restart sequence is kept as a
separately labelled path that also has to settle. Reverting either half of the
fix now fails on frame 1 of the plain sequence rather than on a frame that a
restart card was carrying.

**A five-minute gc timer was being waited out on every run.** The orphaned
suite's own `QueryClient` took React Query's default 300000ms `gcTime`. The one
test that reaches the message-bubble presenter's `useQuery` leaves a query
uncollected, so a 300s timer is armed at teardown and node:test waits it out
before exiting: the file's tests sum to ~2s, the wall was ~303s, and every test
passed, so nothing pointed at the cause. `gcTime: 0` takes the standalone file
from 303s to 7s, and the full desktop suite from 307s to 102s.

The shared rig (`AgentSessionWorkBlockTestRig`) creates a byte-identical client
and gets the same line. It is dormant today — only the relay-bubble test drives
a `useQuery`, and that test builds its own client — but the failure mode if it
ever arms is a silent five-minute hang with no failing assertion, which is
expensive to diagnose and free to prevent.

Verified with a negative control rather than on the passing tree alone: with the
orphaned file restored to its previous blob (17ce5d6) the same command in the
same shell takes 303s, so the number is attributable to the one line.

Contrary to the review note that prompted this, the
'Promise resolution is still pending' marker is NOT present on the previous
commit's tree — grep counts 0 both with and without the fix — so it is not
claimed as fixed here.

Full desktop suite 5491 passing / 0 failing, 81 suites; `tsc --noEmit` clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
…roups it

A reader who opened a finished work block to read its steps was folded shut by
the agent posting another message — an event they did not cause and could not
predict.

Work blocks are derived, not stored. `groupConversationWorkBlocks` rebuilds them
every render and ids each one after its first step, and `findFinalAnswerId`
exempts only the LAST assistant message from the block. So a second assistant
message demotes the first: the earlier answer becomes work, and the runs on
either side of it merge.

    frame 2  work-block:th:1[th:1,tool:1]  msg:1  work-block:th:2[th:2,tool:2]
    frame 3  work-block:th:1[th:1,tool:1,msg:1,th:2,tool:2]  msg:2

`work-block:th:2` stops existing, React unmounts it, and the `useState` holding
the reader's expansion goes with it. The block they were reading is still on
screen — it is just inside a different block now — so this is not a case where
the intent has nowhere to live.

The choice therefore moves above the block, keyed by the STEP ids it was taken
on rather than by block id, since the steps are what the reader's intent was
actually about and they survive regrouping. Two rules follow from the merge:

- **An open choice wins over a folded one.** A merged block can carry both, and
  the two are not symmetric: showing steps a reader did not ask for costs them a
  scroll, hiding steps they did ask for loses what they were reading.
- **A choice is recorded against every step in the block, not just the first.**
  Recording only the first leaves the absorbed block's stale `open` entry behind,
  and since open wins the read, the merged block could never be folded again —
  the reader's click would do nothing. Caught by mutation, not by inspection; the
  first version of the test passed against it, so it now asserts the fold-back.

`useWorkBlockDisclosureStore` returns `null` rather than a no-op store when no
transcript provides one, and the block falls back to local state. A no-op default
would make a block mounted on its own silently swallow every click — how most of
this component's tests mount it — and local state is the correct behaviour in
isolation, where nothing is regrouping anything. A provider emits no DOM, so
`default`/`compactPreview` markup stays byte-identical (the byte-for-byte
baseline test covers this).

Verified by mutation, each applied and reverted in isolation:

- bypass the shared store (i.e. the pre-fix behaviour) — the new test fails on
  the merge frame, `0 !== 1` open blocks, which is exactly the reader-visible
  symptom.
- record the choice against only the first step — fails the fold-back
  assertion, `1 !== 0`.

The test drives the real `AgentSessionTranscriptList` across the three frames so
the actual regrouping runs, and asserts the steps are on screen rather than
trusting a block that reports itself open.

Full desktop suite 5492 passing / 0 failing, 81 suites; `tsc --noEmit` clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen

baxen commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #6911, which consolidates the full agent-activity focus-view stack (plus tho's polish) into one PR against current main, per baxen's call. No further changes will land here.

@baxen baxen closed this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant